원본 항목: Part 1-3. 메모리 경계 계열 — 11. 스택에 할당된 버퍼 오버플로우CWE-121repo 폴더: sd11_stackoverflow/ (빌드·실행은 §3-1)
목표 시간: 1회차 15분 / 2회차 9분 / 3회차 6분
여기부터(SD11~SD19)는 mock이 필요 없다 — 진짜 UB라서 ASan/UBSan이 그대로 잡아준다.
대신 “Bad를 호출하면 실제로 프로세스가 죽는다”를 테스트로 증명하는 새 패턴(expect_crash)을 쓴다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
- char buf[24];- strcpy(buf, input); /* 길이 검사 없는 복사 */+ char buf[24];+ if (strlen(input) < sizeof(buf))+ strncpy(buf, input, sizeof(buf)-1);+ buf[sizeof(buf)-1] = '\0'; /* 반드시 null 종료 */
strcpy/strcat/sprintf/gets 는 보이는 즉시 교체 대상이다.
strncpy 로 바꿔도 마지막 바이트 널 종료는 직접 채워야 한다는 걸 이번 드릴에서 몸에 새긴다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 고정 크기 문자열 버퍼에 복사
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 8바이트 버퍼 + 두 개 필드
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 함수 인자를 구조체로 교체
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
int manipulate_string_good(const char *input, char *out, size_t out_size){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - `strncpy` 는 널 종료를 보장하지 않는다 * - `>=` 와 `>` 의 차이 (널 한 칸) */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
ASan이 위반을 감지하면 프로세스를 즉시 abort() 시킨다.
이 특성을 거꾸로 이용해서 “Bad가 실제로 위험하다”를 자동화된 시험으로 증명한다 — Bad를 자식 프로세스에서 실행하고, 부모가 “자식이 비정상 종료했는가”를 확인한다.
#define _DEFAULT_SOURCE#include <sys/wait.h>#include <unistd.h>/* fn을 자식 프로세스에서 실행한다. 자식이 크래시(시그널 종료 또는 exit!=0)하면 1, 정상 종료(exit 0)하면 0을 반환한다. */static int expect_crash(void (*fn)(void)){ pid_t pid = fork(); if (pid == 0) { fn(); _exit(0); } int status = 0; waitpid(pid, &status, 0); if (WIFSIGNALED(status)) return 1; if (WIFEXITED(status) && WEXITSTATUS(status) != 0) return 1; return 0;}
자식 프로세스가 ASan 리포트를 화면에 찍는다
make check 를 실행하면 PASS 뒤에 AddressSanitizer: stack-buffer-overflow ... 같은 긴 리포트가 함께 출력된다.
이건 실패가 아니라 증거다 — 부모는 그 크래시를 “예상된 결과”로 판정해 PASS 를 출력한 것이다.
이 드릴 이후(SD12~19) 계열에서 계속 재사용하는 패턴이니 잘 기억해 둔다.
3-A. 변형 A 시험 — 이 형태를 고정한다
#define _DEFAULT_SOURCE#include <stdio.h>#include <string.h>#include <sys/wait.h>#include <unistd.h>#include "buf_ops.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static int expect_crash(void (*fn)(void)){ pid_t pid = fork(); if (pid == 0) { fn(); _exit(0); } int status = 0; waitpid(pid, &status, 0); if (WIFSIGNALED(status)) return 1; if (WIFEXITED(status) && WEXITSTATUS(status) != 0) return 1; return 0;}static const char *g_input;static void call_bad(void) { manipulate_string_bad(g_input); }static void test_bad_is_vulnerable(void){ g_input = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; /* 24바이트 훨씬 초과 */ T_TRUE(expect_crash(call_bad) == 1, "Bad는 24바이트를 넘는 입력에서 크래시해야 한다(취약점 재현)");}static void test_bad_normal(void){ g_input = "short"; T_TRUE(expect_crash(call_bad) == 0, "짧은 입력은 크래시 없이 정상 동작해야 한다");}static void test_good_blocks_overflow(void){ char out[24]; T_TRUE(manipulate_string_good( "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", out, sizeof(out)) == -1, "Good은 버퍼보다 긴 입력을 거부해야 한다");}static void test_good_normal(void){ char out[24]; T_TRUE(manipulate_string_good("short", out, sizeof(out)) == 0, "정상 입력은 성공해야 한다"); T_TRUE(strcmp(out, "short") == 0, "복사된 내용이 정확해야 한다");}int main(void){ test_bad_is_vulnerable(); test_bad_normal(); test_good_blocks_overflow(); test_good_normal(); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
#include <string.h>#include "buf_ops.h"void manipulate_string_bad(const char *input){ char buf[24]; strcpy(buf, input); /* 길이 검사 없는 복사 */ (void)buf;}int manipulate_string_good(const char *input, char *out, size_t out_size){ if (strlen(input) >= out_size) return -1; strncpy(out, input, out_size - 1); out[out_size - 1] = '\0'; return 0;}
눈여겨볼 점: manipulate_string_good 은 실패를 명시적으로 반환한다(-1).
PDF 원문의 “그냥 자르고 넘어간다”보다 한 걸음 더 나간 설계다 — 잘린 문자열을 그대로 쓰는 것도 다른 종류의 버그(잘못된 값으로 계속 동작)로 이어질 수 있으므로, 이 드릴에서는 “버퍼에 안 맞으면 실패로 알린다”를 기본값으로 삼는다.